import User, { USER_ROLES } from "@/models/user"; import { getDb } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { requireUserManagement } from "@/lib/auth/permissions"; import { withErrorHandling, json, badRequest, unauthorized, notFound, } from "@/lib/api/errors"; export const dynamic = "force-dynamic"; const BRANCH_RE = /^NL\d+$/; const OBJECT_ID_RE = /^[a-f0-9]{24}$/i; const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const ALLOWED_ROLES = new Set(Object.values(USER_ROLES)); const ALLOWED_UPDATE_FIELDS = Object.freeze([ "username", "email", "role", "branchId", "mustChangePassword", ]); function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } function normalizeUsername(value) { return String(value || "") .trim() .toLowerCase(); } function normalizeEmail(value) { return String(value || "") .trim() .toLowerCase(); } function normalizeBranchId(value) { return String(value || "") .trim() .toUpperCase(); } function toIsoOrNull(value) { if (!value) return null; try { return new Date(value).toISOString(); } catch { return null; } } function toSafeUser(doc) { return { id: String(doc?._id), username: typeof doc?.username === "string" ? doc.username : "", email: typeof doc?.email === "string" ? doc.email : "", role: typeof doc?.role === "string" ? doc.role : "", branchId: doc?.branchId ?? null, mustChangePassword: Boolean(doc?.mustChangePassword), createdAt: toIsoOrNull(doc?.createdAt), updatedAt: toIsoOrNull(doc?.updatedAt), }; } function pickDuplicateField(err) { if (!err || typeof err !== "object") return null; const keyValue = err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null; if (keyValue) { const keys = Object.keys(keyValue); if (keys.length > 0) return keys[0]; } const keyPattern = err.keyPattern && typeof err.keyPattern === "object" ? err.keyPattern : null; if (keyPattern) { const keys = Object.keys(keyPattern); if (keys.length > 0) return keys[0]; } return null; } export const PATCH = withErrorHandling( async function PATCH(request, ctx) { const session = await getSession(); if (!session) { throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized"); } requireUserManagement(session); const { userId } = await ctx.params; if (!userId) { throw badRequest( "VALIDATION_MISSING_PARAM", "Missing required route parameter(s)", { params: ["userId"] }, ); } if (!OBJECT_ID_RE.test(String(userId))) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", { field: "userId", value: userId, }); } let body; try { body = await request.json(); } catch { throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body"); } if (!isPlainObject(body)) { throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body"); } const hasUpdateField = Object.keys(body).some((k) => ALLOWED_UPDATE_FIELDS.includes(k), ); if (!hasUpdateField) { throw badRequest("VALIDATION_MISSING_FIELD", "Missing fields to update", { fields: [...ALLOWED_UPDATE_FIELDS], }); } await getDb(); const user = await User.findById(String(userId)).exec(); if (!user) { throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) }); } const patch = {}; // username (optional) if (Object.prototype.hasOwnProperty.call(body, "username")) { if (!isNonEmptyString(body.username)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", { field: "username", value: body.username, }); } const username = normalizeUsername(body.username); if (!USERNAME_RE.test(username)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", { field: "username", value: username, pattern: String(USERNAME_RE), }); } const existing = await User.findOne({ username, _id: { $ne: String(userId) }, }) .select("_id") .exec(); if (existing) { throw badRequest( "VALIDATION_INVALID_FIELD", "Username already exists", { field: "username", value: username, }, ); } patch.username = username; } // email (optional) if (Object.prototype.hasOwnProperty.call(body, "email")) { if (!isNonEmptyString(body.email)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", { field: "email", value: body.email, }); } const email = normalizeEmail(body.email); if (!EMAIL_RE.test(email)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", { field: "email", value: email, }); } const existing = await User.findOne({ email, _id: { $ne: String(userId) }, }) .select("_id") .exec(); if (existing) { throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", { field: "email", value: email, }); } patch.email = email; } // role (optional) if (Object.prototype.hasOwnProperty.call(body, "role")) { if (!isNonEmptyString(body.role)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", { field: "role", value: body.role, allowed: Array.from(ALLOWED_ROLES), }); } const role = String(body.role).trim(); if (!ALLOWED_ROLES.has(role)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", { field: "role", value: role, allowed: Array.from(ALLOWED_ROLES), }); } patch.role = role; } // branchId (optional, can be null) if (Object.prototype.hasOwnProperty.call(body, "branchId")) { if (body.branchId === null) { patch.branchId = null; } else if (isNonEmptyString(body.branchId)) { patch.branchId = normalizeBranchId(body.branchId); } else { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", { field: "branchId", value: body.branchId, pattern: "^NL\\d+$", }); } } // mustChangePassword (optional) if (Object.prototype.hasOwnProperty.call(body, "mustChangePassword")) { if (typeof body.mustChangePassword !== "boolean") { throw badRequest( "VALIDATION_INVALID_FIELD", "Invalid mustChangePassword", { field: "mustChangePassword", value: body.mustChangePassword, }, ); } patch.mustChangePassword = body.mustChangePassword; } // --- Enforce role <-> branchId consistency -------------------------------- const nextRole = patch.role ?? user.role; const nextBranchId = Object.prototype.hasOwnProperty.call(patch, "branchId") ? patch.branchId : (user.branchId ?? null); if (nextRole === USER_ROLES.BRANCH) { if (!isNonEmptyString(nextBranchId)) { throw badRequest( "VALIDATION_MISSING_FIELD", "Missing required fields", { fields: ["branchId"], }, ); } const normalized = normalizeBranchId(nextBranchId); if (!BRANCH_RE.test(normalized)) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", { field: "branchId", value: normalized, pattern: "^NL\\d+$", }); } patch.branchId = normalized; } else { // For non-branch users, always clear branchId patch.branchId = null; } // --- Apply patch ---------------------------------------------------------- if (Object.prototype.hasOwnProperty.call(patch, "username")) user.username = patch.username; if (Object.prototype.hasOwnProperty.call(patch, "email")) user.email = patch.email; if (Object.prototype.hasOwnProperty.call(patch, "role")) user.role = patch.role; if (Object.prototype.hasOwnProperty.call(patch, "branchId")) user.branchId = patch.branchId; if (Object.prototype.hasOwnProperty.call(patch, "mustChangePassword")) user.mustChangePassword = patch.mustChangePassword; try { await user.save(); } catch (err) { if (err && err.code === 11000) { const field = pickDuplicateField(err) || "unknown"; throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", { field, }); } throw err; } return json({ ok: true, user: toSafeUser(user) }, 200); }, { logPrefix: "[api/admin/users/[userId]]" }, ); export const DELETE = withErrorHandling( async function DELETE(request, ctx) { const session = await getSession(); if (!session) { throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized"); } requireUserManagement(session); const { userId } = await ctx.params; if (!userId) { throw badRequest( "VALIDATION_MISSING_PARAM", "Missing required route parameter(s)", { params: ["userId"] }, ); } if (!OBJECT_ID_RE.test(String(userId))) { throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", { field: "userId", value: userId, }); } // Safety: prevent deleting your own currently active account. if (String(session.userId) === String(userId)) { throw badRequest( "VALIDATION_INVALID_FIELD", "Cannot delete current user", { field: "userId", reason: "SELF_DELETE_FORBIDDEN", }, ); } await getDb(); const deleted = await User.findByIdAndDelete(String(userId)) .select( "_id username email role branchId mustChangePassword createdAt updatedAt", ) .exec(); if (!deleted) { throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) }); } return json({ ok: true, user: toSafeUser(deleted) }, 200); }, { logPrefix: "[api/admin/users/[userId]]" }, );